为什么需要 ref?

JavaScript 的原始值(如 Number、String、null 等)是按值传递的,与对象不同,它们没有引用关系。这意味着我们无法直接通过 Proxy 拦截原始值的读写操作。例如:

let str = 'vue'
str = 'vue3' // 无法拦截

为了让原始值具备响应式能力,Vue.js 引入了 ref 的概念。简单来说,ref 通过一个对象“包裹”原始值,并将这个对象转为响应式,从而间接实现对原始值的监听和响应。

ref 的基本实现

ref 的核心思想是用一个对象(通常称为“包裹对象”)来存储原始值,并通过 Vue 的 reactive 函数将其转为响应式对象。以下是 ref 的简易实现:

function ref(val) {
  const wrapper = {
    value: val
  }
  return reactive(wrapper)
}

通过这样的封装,用户可以这样使用 ref:

const count = ref(1)
effect(() => {
  console.log(count.value) // 1
})
count.value = 2 // 触发副作用,打印 2

在这个例子中,count 是一个响应式对象,访问或修改 count.value 会触发相应的副作用函数(如重新渲染)。这种设计解决了两个问题:

  • 用户无需手动创建包裹对象:ref 函数自动完成包裹对象的创建,简化了开发流程。
  • 规范化包裹对象结构:通过 ref 创建的包裹对象统一使用 value 属性,避免了用户自定义命名带来的不一致性。

如何区分 ref 和普通响应式对象?

尽管 ref 通过包裹对象实现了原始值的响应式,但它与普通的 reactive 对象在结构上并无二致。例如:

const refVal = ref(1)
const reactiveVal = reactive({ value: 1 })

从表面看,refVal 和 reactiveVal 都是包含 value 属性的响应式对象。

那么,如何区分一个对象是否是 ref 呢?这在实现自动脱 ref(稍后介绍)等功能时尤为重要。

Vue.js 的解决方案是为 ref 创建的包裹对象添加一个标识属性 __v_isRef

function ref(val) {
  const wrapper = {
    value: val
  }
  Object.defineProperty(wrapper, '__v_isRef', {
    value: true,
    enumerable: false
  })
  return reactive(wrapper)
}

通过检查 __v_isRef 属性,我们可以明确区分 ref 和普通的 reactive 对象,为后续的优化功能(如自动脱 ref)提供了基础。

解决响应丢失问题

ref 不仅用于原始值的响应式,还能解决 Vue.js 组件开发中的一个常见问题:响应丢失。响应丢失通常发生在使用展开运算符(...)将响应式对象暴露到模板时。来看一个例子:

export default {
  setup() {
    const obj = reactive({ foo: 1, bar: 2 })
    setTimeout(() => {
      obj.foo = 100 // 不会触发模板重新渲染
    }, 1000)
    return { ...obj }
  }
}

在模板中:

<template>
  <p>{{ foo }} / {{ bar }}</p>
</template>

这里的问题在于,{ ...obj } 创建了一个普通对象,丢失了 obj 的响应式特性。

修改 obj.foo 不会触发模板的重新渲染,因为模板访问的是普通对象的 foo 属性,而非响应式的 obj.foo。

toRef 和 toRefs 的解决方案

为了解决响应丢失问题,Vue.js 提供了 toReftoRefs 两个工具函数。它们的思路是通过为响应式对象的每个属性创建一个 ref 结构的对象,保留与原始响应式数据的联系。

toRef 的实现

toRef 函数将响应式对象的某个属性包装为 ref 结构:

function toRef(obj, key) {
  const wrapper = {
    get value() {
      return obj[key]
    },
    set value(val) {
      obj[key] = val
    }
  }
  Object.defineProperty(wrapper, '__v_isRef', {
    value: true
  })
  return wrapper
}

使用 toRef,我们可以这样重构 newObj:

const obj = reactive({ foo: 1, bar: 2 })
const newObj = {
  foo: toRef(obj, 'foo'),
  bar: toRef(obj, 'bar')
}

当访问 newObj.foo.value 时,实际上访问的是 obj.foo,修改 newObj.foo.value 也会更新 obj.foo,从而保留响应式能力。

toRefs 批量转换

如果响应式对象的属性较多,逐个调用 toRef 会很繁琐。因此,Vue.js 提供了 toRefs 函数来批量转换:

function toRefs(obj) {
  const ret = {}
  for (const key in obj) {
    ret[key] = toRef(obj, key)
  }
  return ret
}

使用方式如下:

const obj = reactive({ foo: 1, bar: 2 })
const newObj = { ...toRefs(obj) }

这样,newObj 的每个属性都是一个 ref 结构的对象,访问 newObj.foo.value 相当于访问 obj.foo,修改也会触发响应。

自动脱 ref:优化用户体验

虽然 toRefs 解决了响应丢失问题,但它引入了一个新的挑战:用户必须通过 .value 访问属性值。例如:

const obj = reactive({ foo: 1, bar: 2 })
const newObj = { ...toRefs(obj) }
console.log(newObj.foo.value) // 1

在模板中,用户需要这样写:

<p>{{ foo.value }} / {{ bar.value }}</p>

这显然增加了心智负担,因为用户通常希望直接访问属性值:

<p>{{ foo }} / {{ bar }}</p>

为了解决这个问题,Vue.js 引入了 自动脱 ref 的机制,通过代理自动返回 ref 的 value 属性值。

proxyRefs 的实现

Vue.js 使用 proxyRefs 函数为对象创建代理,实现自动脱 ref:

function proxyRefs(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const value = Reflect.get(target, key, receiver)
      // 自动脱 ref 实现:如果读取的值是 ref,则返回它的 value 属性值
      return value.__v_isRef ? value.value : value
    },
    set(target, key, newValue, receiver) {
      const value = target[key]
      if (value.__v_isRef) {
        value.value = newValue
        return true
      }
      return Reflect.set(target, key, newValue, receiver)
    }
  })
}

使用 proxyRefs 后,访问 newObj.foo 会直接返回 obj.foo 的值,而无需显式访问 .value:

const newObj = proxyRefs({ ...toRefs(obj) })
console.log(newObj.foo) // 1
newObj.foo = 100 // 触发响应,修改 obj.foo

自动脱 ref 的广泛应用

自动脱 ref 不仅用于 toRefs 的场景,在 Vue.js 的其他地方也有体现。例如,reactive 对象中的 ref 属性也会自动脱 ref:

const count = ref(0)
const obj = reactive({ count })
console.log(obj.count) // 0,而非 ref 对象

这种设计极大减轻了用户的心智负担,用户无需关心某个值是否为 ref,只需直接访问即可。